==============================
1-TOWER OF HANOI
==============================
AIM: To solve the Tower of Hanoi problem using recursion and display the sequence of moves.
DESCRIPTION:

The Tower of Hanoi is a classical recursive problem in Artificial Intelligence and Data Structures. 
It consists of three pegs (source, auxiliary, destination) and n disks of different sizes placed on the source peg. 
The objective is to move all disks from the source peg to the destination peg following these rules:

1. Only one disk can be moved at a time.
2. A larger disk cannot be placed on top of a smaller disk.
3. Only the top disk of a peg can be moved.

The problem is solved using recursion. 
To move n disks:
- First move n-1 disks from source to auxiliary.
- Then move the nth disk to destination.
- Finally move n-1 disks from auxiliary to destination.

The minimum number of moves required is 2^n - 1.
Time Complexity: O(2^n)

The Tower of Hanoi is a classic recursive problem. It involves three pegs (source, auxiliary, destination)
and n disks of different sizes stacked on the source peg. The goal is to move all disks to the destination peg. 
Rules: move one disk at a time, never place a larger disk on a smaller one, and only move the top disk. 
The recursive solution moves n-1 disks to the auxiliary, moves the nth disk to the destination, then moves n-1 disks 
to the destination. Minimum moves = 2^n - 1. Time complexity is O(2^n), demonstrating exponential growth. This problem
is fundamental in AI for understanding recursive problem-solving and state-space search.

PROGRAM:
def tower_of_hanoi(n, source, auxiliary, destination):
    if n == 1:
        print(f"Move disk 1 from {source} to {destination}")
        return
    tower_of_hanoi(n - 1, source, destination, auxiliary)
    print(f"Move disk {n} from {source} to {destination}")
    tower_of_hanoi(n - 1, auxiliary, source, destination)

n = 3
tower_of_hanoi(n, "A", "B", "C")

OUTPUT:
Move disk 1 from A to C
Move disk 2 from A to B
Move disk 1 from C to B
Move disk 3 from A to C
Move disk 1 from B to A
Move disk 2 from B to C
Move disk 1 from A to C

==============================
2-VACUUM CLEANER PROBLEM
==============================
AIM: To implement a simple reflex agent for a vacuum cleaner in a two-room environment.
DESCRIPTION:

The Vacuum Cleaner problem is a simple example of a reflex agent in Artificial Intelligence.
The environment consists of two rooms (A and B) which may be in either Dirty or Clean state. 
The agent can perform two actions:
1. SUCK – clean the current room.
2. MOVE – move to the other room.

The agent senses the current room state. 
If the room is dirty, it cleans it. 
If the room is clean, it moves to the other room.

This problem demonstrates:
- Simple reflex agent behavior
- Environment perception and action
- State-based decision making

The Vacuum Cleaner problem models a simple reflex agent in AI. The environment has two rooms (A and B), 
each either Dirty or Clean. The agent perceives the current room's state. If dirty, it performs SUCK to clean it.
If clean, it performs MOVE to the other room. This demonstrates basic agent behavior: perception, action, and state-based 
decision-making without memory of past states. It highlights how a simple rule-based system can maintain a clean environment
efficiently. The problem is foundational for understanding intelligent agents and their interaction with dynamic environments.

PROGRAM:
env = {'A': 'Dirty', 'B': 'Dirty'}
agent = 'A'

for step in range(5):
    print("Step", step + 1, ":", env, "Agent at", agent)
    if env[agent] == 'Dirty':
        print("Action: SUCK")
        env[agent] = 'Clean'
    else:
        print("Action: MOVE")
        if agent == 'A':
            agent = 'B'
        else:
            agent = 'A'

OUTPUT:
Step 1 : {'A': 'Dirty', 'B': 'Dirty'} Agent at A
Action: SUCK
Step 2 : {'A': 'Clean', 'B': 'Dirty'} Agent at A
Action: MOVE
Step 3 : {'A': 'Clean', 'B': 'Dirty'} Agent at B
Action: SUCK
Step 4 : {'A': 'Clean', 'B': 'Clean'} Agent at B
Action: MOVE
Step 5 : {'A': 'Clean', 'B': 'Clean'} Agent at A
Action: MOVE

==============================
3-WATER JUG PROBLEM USING BFS
==============================
AIM: To solve the water jug problem using Breadth First Search (BFS) algorithm.
DESCRIPTION:

The Water Jug problem is a state-space search problem.
Two jugs with fixed capacities are given, and the goal is to measure a specific quantity of water.

The possible operations are:
- Fill a jug
- Empty a jug
- Pour water from one jug to another

The problem is solved using Breadth First Search (BFS). 
Each state is represented as (x, y) where:
x = amount of water in jug1
y = amount of water in jug2

BFS explores all possible states level by level until the target amount is found.
This guarantees finding the shortest solution path.

Time Complexity: O(V + E)
Where V is number of states and E is number of transitions.

The Water Jug problem is a state-space search where two jugs with capacities cap1 and cap2 are used to 
measure a target amount. Operations: fill a jug, empty a jug, or pour water between jugs. Using Breadth-First Search (BFS),
each state (x, y) represents water in jug1 and jug2. BFS explores all states level by level, guaranteeing the shortest solution
path. BFS is ideal because each move has uniform cost. Time complexity is O(V + E) where V is states and E is transitions. This 
problem illustrates uninformed search and state representation in AI.

PROGRAM:
from collections import deque

def water_jug_bfs(cap1, cap2, target):
    visited = set()
    queue = deque()
    queue.append((0, 0))

    while queue:
        x, y = queue.popleft()
        if (x, y) in visited:
            continue
        print((x, y))
        visited.add((x, y))
        if x == target or y == target:
            print("Target achieved!")
            return
        queue.append((cap1, y))
        queue.append((x, cap2))
        queue.append((0, y))
        queue.append((x, 0))
        transfer = min(x, cap2 - y)
        queue.append((x - transfer, y + transfer))
        transfer = min(y, cap1 - x)
        queue.append((x + transfer, y - transfer))

    print("No solution found")

water_jug_bfs(4, 3, 2)

OUTPUT:
(0, 0)
(4, 0)
(0, 3)
(4, 3)
(1, 3)
(3, 0)
(1, 0)
(3, 3)
(0, 1)
(4, 2)
Target achieved!

==============================
4A-RECURSIVE BFS PROGRAM
==============================
AIM: To implement Breadth First Search (BFS) graph traversal algorithm using recursive approach.
DESCRIPTION:


Breadth First Search is a graph traversal algorithm.
It explores nodes level by level starting from a given source node.

Algorithm Steps:
1. Start from the initial node.
2. Mark it as visited.
3. Insert it into a queue.
4. Repeatedly remove a node from the queue.
5. Visit all unvisited neighbors and add them to the queue.

Properties:
- Uses Queue (FIFO)
- Finds shortest path in unweighted graphs
- Time Complexity: O(V + E)

Recursive BFS simulates queue processing using recursion.
Non-recursive BFS uses an explicit queue.

Recursive BFS simulates Breadth-First Search using recursion. BFS explores graph nodes level by level, using 
a queue (FIFO) to process nodes. In recursive BFS, the base case stops when the queue is empty. Each recursive 
call processes one node, enqueues its unvisited neighbors, then recurses. Recursive BFS is less common than 
iterative but demonstrates how recursion can simulate queue-based traversal. It maintains the 
same properties: O(V + E) time complexity and shortest path in unweighted graphs. This approach helps understand 
the relationship between recursion and iterative algorithms.

PROGRAM:
from collections import deque

graph = {
    'A': ['B', 'C'],
    'B': ['D', 'E'],
    'C': ['F'],
    'D': [],
    'E': ['F'],
    'F': []
}

def bfs_recursive(graph, queue, visited):
    if not queue:
        return
    node = queue.popleft()
    print(node, end=" ")
    for neighbor in graph[node]:
        if neighbor not in visited:
            visited.add(neighbor)
            queue.append(neighbor)
    bfs_recursive(graph, queue, visited)

visited = set(['A'])
queue = deque(['A'])

print("Recursive BFS Traversal:")
bfs_recursive(graph, queue, visited)

OUTPUT:
Recursive BFS Traversal:
A B C D E F

==============================
4B-NON-RECURSIVE BFS PROGRAM
==============================
AIM: To implement Breadth First Search (BFS) graph traversal algorithm using non-recursive approach.
DESCRIPTION:


Breadth First Search is a graph traversal algorithm.
It explores nodes level by level starting from a given source node.

Algorithm Steps:
1. Start from the initial node.
2. Mark it as visited.
3. Insert it into a queue.
4. Repeatedly remove a node from the queue.
5. Visit all unvisited neighbors and add them to the queue.

Properties:
- Uses Queue (FIFO)
- Finds shortest path in unweighted graphs
- Time Complexity: O(V + E)

Recursive BFS simulates queue processing using recursion.
Non-recursive BFS uses an explicit queue.

Non-recursive BFS uses an explicit queue to traverse the graph. Starting from the source node,
it marks it visited and enqueues it. While the queue is not empty, it dequeues a node, processes it, 
and enqueues all unvisited neighbors. This iterative approach is standard because recursion depth can
be problematic for large graphs. BFS guarantees finding the shortest path in unweighted graphs. 
Time complexity is O(V + E), space complexity is O(V) for the queue and visited set. This method is 
widely used in AI for state-space search and pathfinding.

PROGRAM:
def bfs_non_recursive(graph, start):
    visited = set([start])
    queue = deque([start])
    while queue:
        node = queue.popleft()
        print(node, end=" ")
        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

print("Non-Recursive BFS Traversal:")
bfs_non_recursive(graph, 'A')

OUTPUT:
Non-Recursive BFS Traversal:
A B C D E F

==============================
5A-RECURSIVE DFS PROGRAM
==============================
AIM: To implement Depth First Search (DFS) graph traversal algorithm using recursive and approach.
DESCRIPTION:

Depth First Search is a graph traversal algorithm.
It explores as deep as possible along one branch before backtracking.

Algorithm Steps:
1. Start from the initial node.
2. Mark it as visited.
3. Recursively visit each unvisited neighbor.

Properties:
- Uses Stack (LIFO)
- Can be implemented using recursion or explicit stack.
- Does not guarantee shortest path.
- Time Complexity: O(V + E)

Recursive DFS uses system call stack.
Non-recursive DFS uses an explicit stack data structure.

Recursive DFS uses the system call stack to traverse the graph depth-first. Starting from the source, 
it marks the node visited, processes it, then recursively visits each unvisited neighbor. This algorithm
explores as deep as possible before backtracking. It does not guarantee the shortest path but uses less 
memory than BFS in sparse graphs. Time complexity is O(V + E). Recursive DFS is elegant for problems like 
topological sorting or cycle detection. However, for large graphs, recursion depth may cause stack overflow,
requiring iterative implementation.

PROGRAM:
def dfs_recursive(graph, node, visited=None):
    if visited is None:
        visited = set()
    print(node, end=" ")
    visited.add(node)
    for neighbor in graph[node]:
        if neighbor not in visited:
            dfs_recursive(graph, neighbor, visited)

print("Recursive DFS:")
dfs_recursive(graph, 'A')

OUTPUT:
Recursive DFS:
A B D E F C

==============================
5B-NON-RECURSIVE DFS PROGRAM
==============================
AIM: To implement Depth First Search (DFS) graph traversal algorithm using non-recursive approach.
DESCRIPTION:

Depth First Search is a graph traversal algorithm.
It explores as deep as possible along one branch before backtracking.

Algorithm Steps:
1. Start from the initial node.
2. Mark it as visited.
3. Recursively visit each unvisited neighbor.

Properties:
- Uses Stack (LIFO)
- Can be implemented using recursion or explicit stack.
- Does not guarantee shortest path.
- Time Complexity: O(V + E)

Recursive DFS uses system call stack.
Non-recursive DFS uses an explicit stack data structure.

Non-recursive DFS uses an explicit stack (LIFO) instead of recursion. It starts by pushing the source node onto 
the stack. While the stack is not empty, it pops a node. If that node is unvisited, it marks it visited, processes it,
and pushes all unvisited neighbors (often in reverse order to mimic recursion order). This approach avoids recursion 
limits and is more memory-efficient for deep graphs. Time complexity remains O(V + E). Iterative DFS is used in AI for
solving mazes, puzzles, and exploring state spaces where recursion depth is unpredictable.

PROGRAM:
def dfs_non_recursive(graph, start):
    visited = set()
    stack = [start]
    while stack:
        node = stack.pop()
        if node not in visited:
            print(node, end=" ")
            visited.add(node)
            for neighbor in reversed(graph[node]):
                if neighbor not in visited:
                    stack.append(neighbor)

print("\nNon-Recursive DFS:")
dfs_non_recursive(graph, 'A')

OUTPUT:
Non-Recursive DFS:
A B D E F C

==============================
6-IMPLEMENTATION OF AI ALGORITHM (A* SEARCH)
==============================
AIM: To implement the A* (A-Star) search algorithm to find the shortest path from a start node to a goal node using heuristics.
DESCRIPTION:

A* (A-star) is an informed search algorithm used in Artificial Intelligence.
It finds the shortest path from a start node to a goal node using heuristics.

It uses the evaluation function:

f(n) = g(n) + h(n)

Where:
g(n) = actual cost from start to node n
h(n) = heuristic estimated cost from node n to goal
f(n) = total estimated cost

A* selects the node with the lowest f(n) value.

Properties:
- Uses priority queue
- Guarantees optimal solution if heuristic is admissible
- More efficient than uninformed search
- Time Complexity depends on heuristic quality

A* is widely used in pathfinding and navigation systems.

A* (A-star) is an informed search algorithm that finds the shortest path using a heuristic. It evaluates nodes using 
f(n) = g(n) + h(n), where g(n) is the actual cost from start to n, and h(n) is the estimated cost from n to the goal.
A* uses a priority queue (min-heap) to explore the most promising nodes first. If h(n) is admissible (never overestimates),
A* guarantees an optimal solution. It is more efficient than uninformed searches like BFS or Dijkstra. Common applications 
include pathfinding in games, robotics, and GPS navigation systems. Time complexity depends on heuristic accuracy.

PROGRAM:
import heapq

graph = {
    'A': [('B', 1), ('C', 3)],
    'B': [('D', 1), ('E', 5)],
    'C': [('F', 2)],
    'D': [],
    'E': [('F', 1)],
    'F': []
}

heuristic = {
    'A': 5,
    'B': 4,
    'C': 2,
    'D': 3,
    'E': 1,
    'F': 0
}

def astar(start, goal, heuristic, graph):
    open_list = []
    heapq.heappush(open_list, (heuristic[start], start))

    g_cost = {start: 0}
    parent = {start: None}
    closed_set = set()

    while open_list:
        _, current = heapq.heappop(open_list)

        if current in closed_set:
            continue

        closed_set.add(current)

        if current == goal:
            path = []
            while current is not None:
                path.append(current)
                current = parent[current]
            return path[::-1]

        for neighbour, cost in graph[current]:
            if neighbour in closed_set:
                continue

            new_cost = g_cost[current] + cost

            if neighbour not in g_cost or new_cost < g_cost[neighbour]:
                g_cost[neighbour] = new_cost
                priority = new_cost + heuristic[neighbour]
                heapq.heappush(open_list, (priority, neighbour))
                parent[neighbour] = current

    return None

result = astar('A', 'F', heuristic, graph)
print("A* Path:", result)

OUTPUT:
A* Path: ['A', 'C', 'F']

==============================
7: IMPLEMENTATION OF CONSTRAINT SATISFACTION PROBLEM (SUDOKU SOLVER)
==============================

AIM:
To solve Sudoku using backtracking.

Backtracking is a systematic search algorithm used for solving Constraint Satisfaction Problems (CSPs). It works
by incrementally building candidates for solutions and abandoning a candidate (backtracking) as soon as it determines that the candidate cannot possibly lead to a 
valid solution. This is a depth-first search approach that explores the search space recursively.

In the context of Sudoku, the algorithm scans the grid to find empty cells (represented by 0). 
For each empty cell, it attempts to place numbers from 1 to 9. Before placing a number, it checks 
constraints: the number must not already exist in the same row, same column, or same 3x3 subgrid. 
If the number is safe, it is placed, and the algorithm calls itself recursively to solve the rest 
of the board. If the recursive call returns true, the puzzle is solved. If it leads to a dead end, 
the algorithm backtracks by resetting the cell to 0 and trying the next number. Time complexity is 
exponential in the worst case (O(9^(n*n))), but Sudoku constraints significantly prune the search space, 
making it efficient for standard 9x9 puzzles. This approach elegantly demonstrates how CSPs can be solved
through systematic search with constraint propagation.

PROGRAM:
def print_board(b):
    for row in b:
        print(row)

def is_safe(row, col, b, num):
    for i in range(9):
        if b[row][i] == num or b[i][col] == num:
            return False

    start_row = (row//3)*3
    start_col = (col//3)*3

    for i in range(3):
        for j in range(3):
            if b[start_row+i][start_col+j] == num:
                return False
    return True

def solve(b):
    for i in range(9):
        for j in range(9):
            if b[i][j] == 0:
                for num in range(1,10):
                    if is_safe(i,j,b,num):
                        b[i][j] = num
                        if solve(b):
                            return True
                        b[i][j] = 0
                return False
    return True

b = []
print("Enter Sudoku:")
for i in range(9):
    b.append(list(map(int,input().split())))

solve(b)
print("\nSolved Sudoku:")
print_board(b)

OUTPUT:
Enter Sudoku:
5 3 0 0 7 0 0 0 0
6 0 0 1 9 5 0 0 0
0 9 8 0 0 0 0 6 0
8 0 0 0 6 0 0 0 3
4 0 0 8 0 3 0 0 1
7 0 0 0 2 0 0 0 6
0 6 0 0 0 0 2 8 0
0 0 0 4 1 9 0 0 5
0 0 0 0 8 0 0 7 9

Solved Sudoku:
[5, 3, 4, 6, 7, 8, 9, 1, 2]
[6, 7, 2, 1, 9, 5, 3, 4, 8]
[1, 9, 8, 3, 4, 2, 5, 6, 7]
[8, 5, 9, 7, 6, 1, 4, 2, 3]
[4, 2, 6, 8, 5, 3, 7, 9, 1]
[7, 1, 3, 9, 2, 4, 8, 5, 6]
[9, 6, 1, 5, 3, 7, 2, 8, 4]
[2, 8, 7, 4, 1, 9, 6, 3, 5]
[3, 4, 5, 2, 8, 6, 1, 7, 9]

==============================
8: IMPLEMENTING ODD & EVEN MAGIC SQUARE
==============================

AIM:
To generate odd and even magic squares.

DESCRIPTION:
A magic square is a mathematical puzzle where an n x n grid is filled with distinct positive integers from 
1 to n² such that the sum of each row, each column, and both main diagonals is identical. This common sum is
known as the magic constant, calculated as n(n²+1)/2. Magic squares have fascinated mathematicians for centuries
due to their elegant properties and have applications in cryptography, game design, and combinatorial mathematics.

This program implements two distinct algorithms for generating magic squares based on the parity of n. For odd-order 
squares (n = 3, 5, 7, ...), it uses the Siamese method: start by placing 1 in the middle of the top row, then move up and 
right for each subsequent number (wrapping around modulo n). If the target cell is already occupied, move down instead.
For doubly even squares (n divisible by 4, like 4, 8, 12, ...), it uses the Strachey method: fill the grid sequentially 
from 1 to n², then complement (replace with n²+1 - value) cells that lie on the main diagonals of each 4x4 subgrid. 
Specifically, cells where (i%4 == j%4) or (i%4 + j%4 == 3) are complemented. These algorithms provide elegant, deterministic
solutions for generating magic squares without trial and error.

PROGRAM:
def odd_magic_square(n):
    magic = [[0]*n for _ in range(n)]
    i, j = 0, n//2
    for num in range(1,n*n+1):
        magic[i][j] = num
        ni, nj = (i-1)%n, (j+1)%n
        if magic[ni][nj]:
            i = (i+1)%n
        else:
            i, j = ni, nj
    return magic

def even_magic_square(n):
    magic = [[(i*n)+j+1 for j in range(n)] for i in range(n)]
    for i in range(n):
        for j in range(n):
            if (i%4 == j%4) or ((i%4 + j%4)==3):
                magic[i][j] = n*n+1 - magic[i][j]
    return magic

choice = input("Enter type (odd/even): ")
n = int(input("Enter n: "))

if choice == "odd":
    res = odd_magic_square(n)
else:
    res = even_magic_square(n)

print("Magic Square:")
for row in res:
    print(row)

OUTPUT:
Enter type (odd/even): even
Enter n: 4

Magic Square:
[16, 2, 3, 13]
[5, 11, 10, 8]
[9, 7, 6, 12]
[4, 14, 15, 1]


==============================
9: TO SIMULATE BASIC LOGIC GATE AND IMPLEMENT SINGLE NEURAL NETWORK (PERCEPTRON)
==============================

AIM: To implement AND, OR, and NOT logic gates using a perceptron (single neural network model).

DESCRIPTION:
The perceptron is the fundamental building block of artificial neural networks, invented by Frank Rosenblatt 
in 1958. It is a binary classifier that computes a weighted sum of its input signals, adds a bias term, and 
passes the result through an activation function (typically a step function) to produce an output. Mathematically, 
output = step(∑(w_i * x_i) + bias), where step(x) = 1 if x ≥ 0 else 0. The perceptron learns by adjusting weights and
bias to correctly classify linearly separable patterns.

This program demonstrates the perceptron's ability to simulate basic logic gates by manually setting appropriate weights 
and bias values. The AND gate requires both inputs to be 1 for output 1, achieved with weights [1,1] and bias -1.5 
(weighted sum threshold is 1.5). The OR gate outputs 1 if at least one input is 1, using the same weights but bias -0.5 (threshold 0.5). 
The NOT gate inverts its single input using weight [-1] and bias 0.5, making the weighted sum become negative only when input is 1.
This implementation illustrates how linear separability works—AND and OR are linearly separable, while XOR is not, highlighting the
perceptron's limitations. This forms the foundation for understanding modern multi-layer neural networks that can solve non-linear problems.

PROGRAM:
import numpy as np

def step(x):
    return 1 if x >= 0 else 0

def perceptron(x, weights, bias):
    return step(np.dot(x, weights) + bias)

inputs = np.array([[0,0],[0,1],[1,0],[1,1]])

print("AND Gate:")
weights = np.array([1,1])
bias = -1.5
for x in inputs:
    print(x, "->", perceptron(x, weights, bias))

print("\nOR Gate:")
bias = -0.5
for x in inputs:
    print(x, "->", perceptron(x, weights, bias))

print("\nNOT Gate:")
weights = np.array([-1])
bias = 0.5
for x in [[0],[1]]:
    print(x, "->", perceptron(x, weights, bias))

OUTPUT:
AND Gate:
[0 0] -> 0
[0 1] -> 0
[1 0] -> 0
[1 1] -> 1

OR Gate:
[0 0] -> 0
[0 1] -> 1
[1 0] -> 1
[1 1] -> 1

NOT Gate:
[0] -> 1
[1] -> 0

==============================
10: USING NLTK LIBRARY AND ITS NATURAL LANGUAGE BASED OPERATIONS (POS TAGGING)
==============================

AIM: To perform Part-of-Speech (POS) tagging using NLTK library.

DESCRIPTION:
Part-of-Speech (POS) tagging is a fundamental Natural Language Processing (NLP) task that assigns grammatical categories—
such as noun (NN), verb (VBZ), adjective (JJ), determinant (DT), or preposition (IN)—to each word in a given text. 
This process is crucial for understanding sentence structure, extracting meaning, and enabling downstream tasks like named 
entity recognition, sentiment analysis, and machine translation. The challenge lies in handling ambiguity, as many words can 
function as different parts of speech depending on context (e.g., "run" can be a noun or verb).

This program leverages NLTK (Natural Language Toolkit), a leading Python library for NLP, to perform POS tagging using the 
averaged_perceptron_tagger. The workflow follows standard text preprocessing: the input sentence is first tokenized into 
individual word tokens using word_tokenize (which separates punctuation as distinct tokens). Punctuation marks are then 
filtered out using string.punctuation to keep only alphabetic tokens. The cleaned tokens are passed to pos_tag(), which 
applies a pre-trained statistical model to predict the most likely POS tag for each word based on its context. Finally, 
common stopwords (frequently occurring words like 'a', 'is', 'the' that carry little semantic meaning) are removed to 
focus on content-bearing words. This pipeline is essential for information extraction, text mining, and building intelligent
chatbots that understand user intent.

PROGRAM:
import nltk
from nltk.tokenize import word_tokenize
from nltk.corpus import stopwords
from nltk import pos_tag
import string

nltk.download('punkt')
nltk.download('averaged_perceptron_tagger')
nltk.download('stopwords')

text = input("Enter sentence: ")

words_with_punct = word_tokenize(text)
words_without_punct = [w for w in words_with_punct if w not in string.punctuation]

pos_tags = pos_tag(words_without_punct)

stop_words = set(stopwords.words('english'))
filtered = [w for w in words_without_punct if w.lower() not in stop_words]

print("Words with punctuation:", words_with_punct)
print("Words without punctuation:", words_without_punct)
print("POS Tags:", pos_tags)
print("After removing stopwords:", filtered)

OUTPUT:
Enter sentence: Hello! This is a simple example.

Words with punctuation:
['Hello', '!', 'This', 'is', 'a', 'simple', 'example', '.']

Words without punctuation:
['Hello', 'This', 'is', 'a', 'simple', 'example']

POS Tags:
[('Hello', 'NNP'), ('This', 'DT'), ('is', 'VBZ'),
 ('a', 'DT'), ('simple', 'JJ'), ('example', 'NN')]

After removing stopwords:
['Hello', 'simple', 'example']

==============================
11: NLTK OPERATIONS ON WORD TOKENIZING AND CORPUS ANALYSIS
==============================

AIM: To perform tokenization (word, sentence, paragraph) and corpus analysis using NLTK.

DESCRIPTION:
Tokenization is the foundational step in text preprocessing, where raw text is segmented into smaller units
called tokens (words, sentences, or paragraphs). This process is more complex than simply splitting by spaces
or periods because natural language includes punctuation, abbreviations (e.g., "Mr." should not split a sentence),
contractions (e.g., "don't"), and multiple sentence boundaries. NLTK's punkt tokenizer is an unsupervised, language-agnostic
tokenizer that has been trained to recognize these patterns, making it highly robust for real-world text.

This program demonstrates three levels of tokenization using NLTK. Sentence tokenization (sent_tokenize) splits text into individual
sentences by detecting boundaries like periods, exclamation marks, and question marks while handling edge cases like abbreviations. 
Word tokenization (word_tokenize) breaks text into words and punctuation tokens, preserving hyphenated words and contractions as single tokens.
Paragraph tokenization is implemented simply by splitting on double newlines ("\n\n"). The second part of the program introduces corpus analysis
using NLTK's Gutenberg corpus—a collection of classic literature. By loading Jane Austen's "Emma," the program calculates total word count (corpus size)
and distinct word count (vocabulary size or lexical diversity). This type of analysis is essential for stylometry, authorship attribution, and understanding
linguistic patterns across different texts and time periods.

PROGRAM (a):
import nltk
from nltk.tokenize import word_tokenize, sent_tokenize

nltk.download('punkt')

text = input("Enter text: ")

print("Word Tokens:", word_tokenize(text))
print("Sentence Tokens:", sent_tokenize(text))
print("Paragraph Tokens:", text.split("\n\n"))

PROGRAM (b):
from nltk.corpus import gutenberg
nltk.download('gutenberg')

words = gutenberg.words('austen-emma.txt')

print("Total words:", len(words))
print("Distinct words:", len(set(words)))

OUTPUT:
Enter text: Hello world. This is NLP.

Word Tokens:
['Hello', 'world', '.', 'This', 'is', 'NLP', '.']

Sentence Tokens:
['Hello world.', 'This is NLP.']

Paragraph Tokens:
['Hello world. This is NLP.']

Total words: 192427
Distinct words: 7811

==============================
12: IMPLEMENT CALENDAR AND CALCULATOR USING PYTHON
==============================

AIM:
To perform arithmetic operations using a calculator and display calendar for a given month and year.

DESCRIPTION:
A calculator is one of the most fundamental programming exercises, teaching basic arithmetic operations, conditional logic, 
and user input handling. It takes two numeric inputs from the user along with an operator (+, -, *, /), then performs the
corresponding operation and displays the result. The addition operator (+) returns the sum of two numbers. Subtraction (-) 
returns the difference. Multiplication (*) returns the product. Division (/) returns the quotient as a floating-point number,
which is automatically handled by Python's float conversion. Proper error handling can be added for division by zero scenarios. 
This exercise reinforces understanding of data types (float for decimal numbers), conditional statements (if-elif-else), and the 
importance of input validation.

The calendar program demonstrates how to leverage Python's extensive standard library to perform complex tasks with minimal code.
The calendar module provides calendar.month(year, month), which returns a formatted multi-line string representing the calendar for
that specific month. The output displays days with proper alignment: Monday is the first column (Mo) and Sunday the last (Su), with 
each week on a new line. This module handles varying month lengths (28-31 days) and leap years automatically by utilizing Python's 
datetime capabilities. Together, these programs showcase two different paradigms: implementing logic manually (calculator) versus 
utilizing built-in libraries for specialized functionality (calendar). This duality helps beginners understand when to write custom 
code and when to leverage existing solutions for efficiency and reliability.

PROGRAM:
# Calculator
num1 = float(input("Enter first number: "))
num2 = float(input("Enter second number: "))
op = input("Enter operator (+,-,*,/): ")

if op == '+':
    print("Result =", num1 + num2)
elif op == '-':
    print("Result =", num1 - num2)
elif op == '*':
    print("Result =", num1 * num2)
elif op == '/':
    print("Result =", num1 / num2)
else:
    print("Invalid operator")

# Calendar
import calendar
year = int(input("Enter year: "))
month = int(input("Enter month: "))
print(calendar.month(year, month))

OUTPUT:
Enter first number: 10
Enter second number: 5
Enter operator (+,-,*,/): +

Result = 15.0

Enter year: 2025
Enter month: 4

    April 2025
Mo Tu We Th Fr Sa Su
    1  2  3  4  5  6
 7  8  9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
